들어가며

이번에는 서버 사이드 렌더링을 정리했습니다.

SPA와 SSR을 비교하고, 리액트가 서버에서 HTML을 만들어내는 API들을 살펴봅니다. 그리고 뒷부분에서는 조금 다른 이야기로 이어집니다. SSR을 하려면 HTML만 보내서는 안 되고 CSS도 같이 실어보내야 하는데, 요즘 라이브러리들이 스타일을 대체 어디에 넣고 있는가에 대한 이야기입니다.


SPA / CSR vs MPA / SSR

현대의 앱은 SPA 형식으로 렌더링하는 곳이 꽤 많습니다. 이유는 명확합니다.

유저에게 더 나은 UX를 제공하기 위해서입니다. 페이지를 이동할 때 서버에서 문서를 새로 받지 않으므로 더 빠른 상호작용이 가능합니다. 반면 전통적인 SSR은 페이지가 바뀔 때마다 새로운 HTML을 받아야 하니 그만큼 시간이 걸립니다.

Gmail이 대표적인 SPA입니다. 메일을 열고 닫고 이동하는 내내 문서가 새로 로드되지 않습니다.

그런데 여기서 역설적인 상황이 생깁니다.

자바스크립트에 많은 부분을 의존하다 보니, 정작 최초 렌더링 시간이 길어지는 현상이 발생합니다.

빈 HTML을 받고 → 번들을 내려받고 → 파싱하고 → 실행해야 비로소 첫 화면이 나옵니다. 이동은 빨라졌는데 첫 진입이 느려진 셈입니다.


SSR의 장점

FCP가 짧다

서버가 이미 완성된 HTML을 보내주므로 첫 콘텐츠가 화면에 빨리 그려집니다. SPA가 겪는 “빈 화면 구간”이 없습니다.

검색 엔진과 SEO에 유리하다

개인적으로는 이것이 SSR을 택하는 가장 큰 실질적 이유가 아닐까 싶습니다.

크롤러가 자바스크립트를 실행하지 않거나, 실행하더라도 후순위로 미루는 경우가 있습니다. 서버가 완성된 마크업을 내려주면 이 문제에서 자유롭습니다. 커머스나 미디어처럼 검색 유입이 매출과 직결되는 서비스에서는 다른 장점을 다 합친 것보다 이 하나가 큽니다.

누적 레이아웃 이동(CLS)이 적다

데이터가 이미 채워진 상태로 오기 때문에 콘텐츠가 나중에 끼어들며 화면을 밀어내는 일이 적습니다.

다만 이건 SPA에서도 대응이 가능합니다. 영역을 미리 지정하는 스켈레톤 UI가 그 대응책입니다. 자리를 먼저 잡아두면 데이터가 늦게 와도 레이아웃이 흔들리지 않습니다.

보안에 조금 더 안전하다

그런데 여기서 의문이 하나 생겼습니다.

그러면 Gmail은 SPA인데 어떻게 보안을 챙길 수 있었을까?

정리해보니, “SSR이 더 안전하다”는 말은 생각보다 좁은 의미였습니다.

CSR은 로직과 설정이 전부 번들에 담겨 클라이언트로 내려갑니다. 그래서 실수로 흘리기 쉽습니다. API 키를 상수로 박아두거나, 관리자 판별 로직을 프론트에만 두거나 하는 식입니다. SSR은 그 코드가 서버에 남으니 이런 실수의 여지가 줄어듭니다.

하지만 실제 보안은 렌더링 방식이 결정하지 않습니다.

  • 권한 검사는 어차피 서버 API에서 해야 합니다. 클라이언트가 무엇을 보여주든 서버가 거부하면 그만입니다.
  • 세션 토큰은 HttpOnly 쿠키에 담아 자바스크립트가 읽지 못하게 합니다.
  • CSP로 스크립트 출처를 제한합니다.

Gmail의 프론트엔드는 결국 뷰(view)일 뿐입니다. 화면에서 “삭제” 버튼을 숨기든 말든, 삭제 권한은 서버가 판단합니다. 그러니 SSR의 보안 이점은 “안전하다”가 아니라 “덜 흘린다” 정도로 이해하는 게 맞겠다는 결론입니다.


SSR의 단점

  • 서버를 생각하며 프로그래밍해야 합니다. windowdocument가 없는 환경을 항상 염두에 둬야 하고, 서버와 클라이언트 양쪽에서 도는 코드를 구분해야 합니다.
  • 적절한 서버가 필요합니다. 정적 호스팅으로 끝나지 않고, 요청마다 렌더링을 감당할 서버와 그 운영 비용이 생깁니다.

결국 SPA와 SSR 모두 각각의 장단점이 있다는 것을 알 수 있습니다. 어느 하나가 정답인 구도가 아닙니다.


현대의 SSR

그래서 지금 우리가 쓰는 방식은 둘 중 하나를 고르는 게 아니라 둘을 이어 붙인 형태입니다.

최초에는 SSR 형식으로 필요한 문서만 받고, 이후 JS에 의해 SPA와 동일한 환경을 제공합니다.

  • 첫 진입: 서버가 완성된 HTML을 보냄 → FCP가 빠르고 크롤러도 읽을 수 있음
  • 그 이후: 자바스크립트가 붙어서 그 HTML을 살아 있는 앱으로 만듦 → 이동이 빠름

이 “붙이는” 과정이 하이드레이션(Hydration) 입니다. 서버가 만든 마른 HTML에 이벤트라는 물을 붓는다는 비유입니다.


React에서 SSR 제공하기

리액트는 react-dom/server를 통해 서버 렌더링 API를 제공합니다. 이 모듈은 브라우저의 window가 아니라 Node.js에서 구동됩니다.

renderToString

인수로 받은 컴포넌트를 문자열로 파싱하여 보내줍니다.

import { renderToString } from 'react-dom/server'

const html = renderToString(<App />)
// <div data-reactroot=""><h1>안녕하세요</h1></div>

중요한 것은 useEffect나 이벤트 핸들러는 포함되지 않는다는 점입니다. 결과물은 어디까지나 정적인 마크업 문자열이고, onClick을 아무리 달아놔도 그 문자열에는 흔적조차 남지 않습니다. 동작은 하이드레이션이 붙어야 생깁니다.

여기서 data-reactroot가 중요합니다. 이 마크업이 리액트가 렌더링한 트리의 루트라는 표식이고, 하이드레이션할 때 리액트가 이 지점을 기준으로 삼습니다.

renderToStaticMarkup

위 함수와 동일한 기능을 제공하나, data-reactroot 같은 리액트 속성을 제외하고 순수한 마크업만 리턴합니다.

import { renderToStaticMarkup } from 'react-dom/server'

const html = renderToStaticMarkup(<App />)
// <div><h1>안녕하세요</h1></div>

표식이 없으므로 이 결과물로는 하이드레이션을 수행할 수 없습니다. 따라서 이벤트가 전혀 필요 없는 간단한 UI에만 써야 합니다. 이메일 템플릿이나 정적 문서 같은 경우입니다.

renderToNodeStream

역시 renderToString과 동일한 기능이지만 두 가지가 다릅니다.

  • 브라우저에서는 절대 사용할 수 없습니다.
  • 리턴 타입이 Node의 ReadableStream입니다.

쓰는 이유는 거대한 컴포넌트나 템플릿일 때 청크를 분리해서 보내기 위해서입니다. 문자열 하나를 통째로 만들어 한 번에 보내면 그만큼 메모리를 쓰고, 클라이언트는 전부 완성될 때까지 아무것도 받지 못합니다. 스트림으로 나눠 보내면 만들어지는 대로 흘려보낼 수 있습니다.

renderToStaticNodeStream

위와 같은 스트림 방식이면서, 리액트 속성이 없는 결과물을 반환합니다. 마찬가지로 하이드레이션이 불가능합니다.

hydrate

앞의 함수들로 만들어진 마크업에 이벤트를 붙이는 일을 합니다.

  • renderToStaticMarkup 계열로 반환한 결과물에서는 불가능합니다.
  • 이미 렌더링된 컴포넌트와 그 위치를 인수로 받습니다.
import { hydrate } from 'react-dom'

hydrate(<App />, document.getElementById('root'))

정리하면 이렇습니다.

함수 스트림 리액트 속성 하이드레이션
renderToString 가능
renderToStaticMarkup 불가능
renderToNodeStream 가능
renderToStaticNodeStream 불가능

18 버전에서 달라진 것

책의 내용을 그대로 따라가다 보면 지금 코드와 어긋나는 부분이 있어서 따로 정리해둡니다.

hydrate → hydrateRoot

18버전부터는 사실상 hydrate가 아닌 hydrateRoot 를 사용합니다. 클라이언트 렌더링이 ReactDOM.render에서 createRoot로 바뀐 것과 같은 맥락입니다.

import { hydrateRoot } from 'react-dom/client'

hydrateRoot(document.getElementById('root'), <App />)

인수 순서가 뒤집혔다는 점도 같이 봐두면 좋습니다. hydrate(엘리먼트, 컨테이너) 였던 것이 hydrateRoot(컨테이너, 엘리먼트) 가 됐습니다.

data-reactroot가 사라졌다

React 18부터는 renderToStringdata-reactroot를 붙이지 않습니다. 하이드레이션 방식이 바뀌면서 이 표식이 더 이상 필요 없어졌기 때문입니다.

그래서 앞에서 “중요하다”고 적어둔 이 속성은, 지금 코드에서 찾으면 나오지 않습니다. 개념을 이해하는 용도로만 알아두면 됩니다.

스트림 API가 교체됐다

renderToNodeStreamrenderToStaticNodeStream은 18에서 deprecated 되고 다음으로 대체됐습니다.

  • renderToPipeableStream — Node.js 환경
  • renderToReadableStream — Web Streams 환경 (Edge 런타임 등)
import { renderToPipeableStream } from 'react-dom/server'

const { pipe } = renderToPipeableStream(<App />, {
  bootstrapScripts: ['/main.js'],
  onShellReady() {
    response.setHeader('Content-Type', 'text/html')
    pipe(response)
  },
})

단순히 이름만 바뀐 게 아닙니다. 새 API는 Suspense와 맞물려 동작합니다.

느린 데이터에 의존하는 영역을 Suspense로 감싸두면, 서버는 그 부분을 기다리지 않고 준비된 껍데기(shell)부터 먼저 흘려보냅니다. 그리고 준비되는 대로 나머지를 이어 보냅니다. 하이드레이션도 마찬가지로 전체를 한 번에 하지 않고, 사용자가 상호작용한 영역부터 선택적으로 진행합니다.

기존 renderToString이 “전부 완성될 때까지 아무것도 못 보냄”이었다면, 새 방식은 “되는 것부터 보냄”입니다.


여기서 이어지는 질문: 그럼 CSS는?

서버에서 HTML을 문자열로 만들어 보낸다는 건, 그 HTML에 맞는 CSS도 같이 실어보내야 한다는 뜻입니다. 스타일이 늦게 도착하면 스타일 없는 화면이 잠깐 번쩍이게 됩니다.

그래서 CSS-in-JS 라이브러리들은 “스타일을 언제, 어디에 주입할 것인가”를 각자의 방식으로 풀고 있습니다. 이 지점을 들여다보다가 궁금해진 것이 두 가지였습니다.

  • Next.js 프로젝트에서 봤던 SPEEDY_MODE는 정확히 무엇인가
  • Lit의 static styles 는 어떻게 동작하는가

둘 다 DevTools에서 보면 <style> 태그 안에 CSS가 안 보이는” 공통된 현상을 보여서 같이 정리하게 됐습니다.


SPEEDY_MODE

먼저 짚어둘 것은, 이건 Next.js의 기능이 아니라 styled-components의 최적화 모드라는 점입니다.

styled-components는 프로덕션에서 SPEEDY_MODE를 켜고 동작합니다. 이 모드가 켜져 있으면 <style> 태그의 textContent에는 CSS가 거의 보이지 않고, 자바스크립트에서 CSSStyleSheet.insertRule()CSSOM에 직접 룰을 꽂아 넣습니다.

그래서 DevTools의 Elements 패널에서 보면 이렇게 보입니다.

<!-- 개발 모드: 텍스트로 들어 있습니다 -->
<style data-styled="active">
  .hXbqTP { color: red; }
</style>

<!-- 프로덕션(SPEEDY): 태그는 있는데 비어 보입니다 -->
<style data-styled="active"></style>

실제 룰은 document.styleSheets 안에만 존재합니다.

document.querySelector('style[data-styled]').textContent
// '' — 비어 있습니다

document.styleSheets[0].cssRules
// CSSRuleList { 0: CSSStyleRule, ... } — 여기 있습니다

여기서 오해하기 쉬운 지점이 있습니다. “style 태그 안에 CSS를 넣지 않는다” 는 느낌은, 정확히는 “태그는 있는데 텍스트 노드로는 안 보인다” 에 가깝습니다. 진짜로 <style> 태그를 안 쓰는 게 아닙니다.

왜 이렇게 하냐면, <style>에 텍스트를 덧붙이는 방식은 브라우저가 그 CSS를 다시 파싱해야 합니다. 룰이 하나 추가될 때마다 반복되면 비용이 커집니다. insertRule() 은 이미 파싱된 CSSOM에 룰 객체를 바로 꽂아 넣기 때문에 훨씬 빠릅니다. 그래서 이름이 “speedy” 입니다.


Lit의 static styles

Lit에서는 보통 이렇게 씁니다.

class MyElement extends LitElement {
  static styles = css`
    :host {
      display: block;
    }
    .foo {
      color: red;
    }
  `
}

내부 구현은 대략 두 가지 경로가 있습니다.

1. adoptedStyleSheets를 지원하는 경우

브라우저가 Constructable Stylesheet를 지원하면 이 경로를 탑니다.

const sheet = new CSSStyleSheet()
sheet.replaceSync(cssText)
shadowRoot.adoptedStyleSheets = [sheet]

new CSSStyleSheet() 로 스타일 시트를 만들고, replaceSync(cssText) 로 CSS를 채워 넣은 다음, shadowRoot.adoptedStyleSheets 에 붙입니다.

이때는 shadowRoot 안에 <style> 태그가 아예 생기지 않을 수 있습니다. 스타일은 CSSStyleSheet 객체에 들어가 있고, shadowRoot는 그걸 참조만 하기 때문입니다.

2. 지원하지 않는 경우

폴백으로 shadowRoot 안에 <style>...</style> 를 직접 만들어서 CSS 텍스트를 넣습니다.

즉 Lit은 환경에 따라 <style> 태그에 텍스트로 넣거나, CSSStyleSheet + adoptedStyleSheets 로 넣거나 둘 중 하나를 선택하는 구조입니다.

여기서 static이라는 키워드가 왜 붙어 있는지가 드러납니다. 시트를 클래스 단위로 딱 한 번 만들어서 모든 인스턴스가 공유하기 때문입니다. 같은 컴포넌트를 1,000개 띄워도 스타일 시트 객체는 하나입니다. <style> 태그를 1,000개 만드는 것과는 비용이 전혀 다릅니다.


둘의 공통점과 차이점

공통점

둘 다 최종적으로는 CSSOM에 룰이 들어갑니다.

그래서 DevTools에서 DOM만 보면 비슷한 인상을 받습니다. styled-components(SPEEDY_MODE)는 <style>이 비어 보이고, Lit + adoptedStyleSheets는 <style> 태그가 아예 안 보이기도 해서 “어? style 안에 CSS가 없네?” 싶어집니다.

DOM은 스타일이 사는 유일한 장소가 아니라는 것, 이게 두 사례가 공통으로 알려주는 부분이었습니다.

차이점

인상이 비슷할 뿐, 목적과 개념 자체가 다릅니다.

styled-components SPEEDY_MODE Lit static styles
정체 성능 최적화 모드 스타일 선언의 기본 메커니즘
주입 방식 insertRule() adoptedStyleSheets (미지원 시 <style>)
스코프 해시 클래스명 Shadow DOM
스타일 생성 런타임에 props 기반으로 생성 클래스 단위로 한 번 생성
공유 단위 props 조합마다 룰이 늘어남 클래스당 시트 하나를 전 인스턴스가 공유

SPEEDY_MODE는 styled-components의 성능 최적화 모드입니다. 원래 <style>에 텍스트를 넣던 것을 더 빠른 insertRule 기반으로 바꾼 것이고, 켜고 끌 수 있는 옵션입니다.

Lit의 static styles는 “성능 모드”가 아니라 “이 컴포넌트의 스코프된 CSS는 이렇게 관리한다”는 코어 메커니즘 그 자체입니다. 끄고 켜는 개념이 아닙니다.

동적/정적 관점에서도 갈립니다.

  • styled-components는 런타임에 props를 받아 CSS를 생성해 넣는 전형적인 CSS-in-JS입니다.
  • Lit의 static styles는 말 그대로 static입니다. 인스턴스별 동적 스타일은 CSS 변수나 클래스, inline style로 푸는 것이 원칙입니다.
// Lit — 인스턴스마다 다른 값은 CSS 변수로 넘깁니다
static styles = css`
  .box {
    background: var(--box-color, gray);
  }
`

정리

  • SPA와 SSR은 우열이 아니라 트레이드오프입니다. SPA는 이동이 빠른 대신 첫 진입이 느리고, SSR은 그 반대입니다.
  • 현대의 SSR은 둘 중 하나를 고르지 않고, 첫 문서는 서버가 만들고 그 뒤는 JS가 이어받는 하이브리드입니다.
  • 리액트의 서버 렌더링 API는 “리액트 속성을 남기느냐(하이드레이션 가능)”“스트림이냐” 두 축으로 나뉩니다.
  • 18버전에서 hydrateRoot와 스트리밍 SSR로 옮겨간 것은, “전부 완성될 때까지 기다림”에서 “되는 것부터 보냄” 으로의 전환이었습니다.
  • CSS-in-JS 라이브러리들이 스타일을 <style> 텍스트가 아니라 CSSOM에 직접 넣는 방향으로 간 것도 결이 같습니다. 브라우저가 다시 파싱하지 않게 하려는 것입니다.

마지막으로 하나 덧붙이면, 런타임 CSS-in-JS는 서버 컴포넌트와 잘 맞지 않습니다. props를 받아 런타임에 스타일을 만들어내는 방식은 서버에서 실행이 끝나버리는 컴포넌트와 전제가 어긋나기 때문입니다. styled-components가 최근 유지보수 모드로 방향을 튼 배경에도 이 문제가 있습니다.

SSR을 이해하는 일이 결국 스타일을 어디에 넣을지의 문제까지 이어진다는 게, 이번에 정리하면서 가장 재미있었던 부분입니다.